Skip to content
Closed
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
3 changes: 3 additions & 0 deletions src/shade/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
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

__all__ = [
"AssetBalance",
"Balance",
"Merchant",
"Payment",
"PaymentStatus",
"ShadeObject",
"Transfer",
"TransferStatus",
Expand Down
43 changes: 43 additions & 0 deletions src/shade/models/payment.py
Original file line number Diff line number Diff line change
@@ -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
114 changes: 114 additions & 0 deletions tests/models/test_payment.py
Original file line number Diff line number Diff line change
@@ -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
Loading