diff --git a/src/shade/__init__.py b/src/shade/__init__.py index 50ec3ee..b119961 100644 --- a/src/shade/__init__.py +++ b/src/shade/__init__.py @@ -22,6 +22,8 @@ from .models import ( AssetBalance, Balance, + Invoice, + InvoiceStatus, Merchant, ShadeObject, Transfer, @@ -42,6 +44,8 @@ "Gateway", "HTTPError", "InvalidRequestError", + "Invoice", + "InvoiceStatus", "Merchant", "NetworkError", "NotFoundError", diff --git a/src/shade/models/__init__.py b/src/shade/models/__init__.py index 1e70fb5..87c3233 100644 --- a/src/shade/models/__init__.py +++ b/src/shade/models/__init__.py @@ -3,6 +3,7 @@ """ from .balance import AssetBalance, Balance from .base import ShadeObject +from .invoice import Invoice, InvoiceStatus from .merchant import Merchant from .transfer import Transfer, TransferStatus from .webhook import WebhookEvent, WebhookEventType @@ -10,6 +11,8 @@ __all__ = [ "AssetBalance", "Balance", + "Invoice", + "InvoiceStatus", "Merchant", "ShadeObject", "Transfer", diff --git a/src/shade/models/invoice.py b/src/shade/models/invoice.py new file mode 100644 index 0000000..3ec5008 --- /dev/null +++ b/src/shade/models/invoice.py @@ -0,0 +1,190 @@ +""" +Invoice model. + +A faithful mirror of the Soroban smart contract's invoice struct: a single +description + amount + token tuple, with no line items. Contract-native +types are converted to their Python equivalents on the way in — ``u64`` +identifiers/timestamps to :class:`int`/:class:`~datetime.datetime`, and +``i128`` monetary amounts to :class:`~decimal.Decimal` — so callers never +have to reason about on-chain wire types. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal +from enum import Enum +from typing import Optional + +from pydantic import field_validator + +from ..errors import InvalidRequestError +from .base import ShadeObject + +_U64_FIELDS = ("id", "merchant_id") +_I128_FIELDS = ("amount", "amount_paid", "amount_refunded") +_TIMESTAMP_FIELDS = ("date_created", "date_paid", "expires_at") +_U64_MAX = 2**64 - 1 + + +def _validate_u64(field: str, value: object) -> int: + """Coerce ``value`` to a strictly valid on-chain ``u64``. + + Booleans and floats are rejected outright rather than coerced, since a + silent bool->int or float->int cast could hide a malformed contract + payload. Everything else is coerced via ``int()`` and range-checked + against ``[0, 2**64 - 1]``. + """ + if isinstance(value, bool): + raise ValueError(f"{field} must be an integer, not a boolean") + if isinstance(value, float): + raise ValueError(f"{field} must be an integer, not a float") + if isinstance(value, int): + result = value + else: + try: + result = int(value) + except (TypeError, ValueError) as exc: + raise ValueError( + f"{field} must be an integer, got {type(value).__name__}" + ) from exc + if not 0 <= result <= _U64_MAX: + raise ValueError( + f"{field} must be between 0 and {_U64_MAX} (u64 range), got {result}" + ) + return result + + +class InvoiceStatus(str, Enum): + """Lifecycle status of an invoice, as tracked by the Soroban contract.""" + + PENDING = "pending" + PAID = "paid" + EXPIRED = "expired" + PARTIALLY_PAID = "partially_paid" + REFUNDED = "refunded" + + +class Invoice(ShadeObject): + """An invoice, mirroring the Soroban contract's on-chain struct. + + Build one from a raw contract response with :meth:`from_dict`, which + converts ``u64`` identifiers/timestamps and ``i128`` amounts into their + Python equivalents via :meth:`_from_contract_dict` before validation. + There are no line items on-chain: a single ``description`` + ``amount`` + + ``token`` tuple is the whole invoice body. + + Attributes: + id: The on-chain ``u64`` invoice ID. + description: Free-text description of what the invoice is for. + amount: Total amount due, converted from the contract's ``i128``. + token: Contract address of the token the invoice is denominated in. + status: Current lifecycle status. + merchant_id: The merchant's numeric ID, as stamped by the contract. + payer: Address of the account that paid, ``None`` until paid. + date_created: When the invoice was created on-chain. + date_paid: When the invoice was paid, ``None`` until paid. + amount_paid: Amount paid so far, converted from the contract's ``i128``. + amount_refunded: Amount refunded so far, converted from the + contract's ``i128``. + expires_at: When the invoice expires, if it has an expiry. + """ + + id: int + description: str + amount: Decimal + token: str + status: InvoiceStatus + merchant_id: int + payer: Optional[str] = None + date_created: datetime + date_paid: Optional[datetime] = None + amount_paid: Decimal + amount_refunded: Decimal + expires_at: Optional[datetime] = None + + @field_validator("id", "merchant_id", mode="before") + @classmethod + def _reject_bool_ids(cls, value: object) -> object: + # pydantic would otherwise coerce True/False to 1/0; a bool is never + # a valid on-chain u64 id. + if isinstance(value, bool): + raise ValueError("must be an integer, not a boolean") + return value + + @property + def is_paid(self) -> bool: + """Whether the invoice has been paid in full.""" + return self.status is InvoiceStatus.PAID + + @property + def is_expired(self) -> bool: + """Whether ``expires_at`` has passed, regardless of ``status``.""" + return self.expires_at is not None and self.expires_at < datetime.now( + timezone.utc + ) + + @property + def outstanding(self) -> Decimal: + """Amount still owed: ``amount`` minus ``amount_paid``.""" + return self.amount - self.amount_paid + + @classmethod + def _from_contract_dict(cls, data: dict) -> dict: + """Convert raw Soroban contract field types to their Python equivalents. + + ``u64`` identifiers become :class:`int`, ``i128`` amounts become + :class:`~decimal.Decimal`, and ``u64`` Unix timestamps become UTC + :class:`~datetime.datetime`. Values that are already the target type + (or ``None``) pass through untouched, so re-running a previously + converted dict — as happens on a ``to_dict()`` -> ``from_dict()`` + round trip — is a no-op rather than a double conversion. + + Raises: + ValueError: If a ``u64`` or timestamp field is a boolean, a + float, negative, or exceeds ``2**64 - 1``. + OverflowError: If a timestamp is within the valid ``u64`` range + but too large for :class:`~datetime.datetime` to represent. + """ + converted = dict(data) + + for field in _U64_FIELDS: + value = converted.get(field) + if value is None: + continue + converted[field] = _validate_u64(field, value) + + for field in _I128_FIELDS: + value = converted.get(field) + if value is None or isinstance(value, Decimal): + continue + converted[field] = Decimal(str(value)) + + for field in _TIMESTAMP_FIELDS: + value = converted.get(field) + if value is None or isinstance(value, datetime): + continue + timestamp = _validate_u64(field, value) + converted[field] = datetime.fromtimestamp(timestamp, tz=timezone.utc) + + return converted + + @classmethod + def from_dict(cls, data: dict) -> "Invoice": + """Build an :class:`Invoice` from a raw contract (or round-tripped) dict. + + Raises: + InvalidRequestError: If ``data`` is not a dict or fails validation. + """ + if not isinstance(data, dict): + raise InvalidRequestError( + f"{cls.__name__}.from_dict() expects a dict, got " + f"{type(data).__name__}" + ) + try: + converted = cls._from_contract_dict(data) + except (ValueError, OverflowError) as err: + field = str(err).split(" ", 1)[0] + param = field if field in (*_U64_FIELDS, *_TIMESTAMP_FIELDS) else None + raise InvalidRequestError(str(err), param=param) from err + return cls(**converted) diff --git a/tests/test_invoice.py b/tests/test_invoice.py new file mode 100644 index 0000000..6115cf9 --- /dev/null +++ b/tests/test_invoice.py @@ -0,0 +1,250 @@ +from datetime import datetime, timedelta, timezone +from decimal import Decimal + +import pytest +from stellar_sdk import Keypair + +import shade +from shade import InvalidRequestError, Invoice, InvoiceStatus, ShadeObject + +TOKEN = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA" +PAYER = Keypair.random().public_key + +NOW = int(datetime(2026, 8, 1, tzinfo=timezone.utc).timestamp()) +FUTURE = int((datetime.now(timezone.utc) + timedelta(days=30)).timestamp()) +PAST = int(datetime(2020, 1, 1, tzinfo=timezone.utc).timestamp()) +U64_MAX = 2**64 - 1 + + +def _contract_response(**overrides): + """A representative raw Soroban contract payload (u64/i128 wire types).""" + data = { + "id": 42, + "description": "Consulting services - August", + "amount": 150000000, + "token": TOKEN, + "status": "pending", + "merchant_id": 7, + "payer": None, + "date_created": NOW, + "date_paid": None, + "amount_paid": 0, + "amount_refunded": 0, + "expires_at": FUTURE, + } + data.update(overrides) + return data + + +def test_from_dict_populates_all_fields(): + invoice = Invoice.from_dict(_contract_response()) + + assert invoice.id == 42 + assert invoice.description == "Consulting services - August" + assert invoice.amount == Decimal("150000000") + assert invoice.token == TOKEN + assert invoice.status == InvoiceStatus.PENDING + assert invoice.merchant_id == 7 + assert invoice.payer is None + assert invoice.date_created == datetime.fromtimestamp(NOW, tz=timezone.utc) + assert invoice.date_paid is None + assert invoice.amount_paid == Decimal("0") + assert invoice.amount_refunded == Decimal("0") + assert invoice.expires_at == datetime.fromtimestamp(FUTURE, tz=timezone.utc) + + +def test_amount_fields_are_decimal_not_int_or_float(): + invoice = Invoice.from_dict( + _contract_response(amount=999, amount_paid=100, amount_refunded=1) + ) + assert isinstance(invoice.amount, Decimal) + assert isinstance(invoice.amount_paid, Decimal) + assert isinstance(invoice.amount_refunded, Decimal) + assert not isinstance(invoice.amount, float) + + +def test_timestamp_fields_are_datetime_not_int(): + invoice = Invoice.from_dict(_contract_response(date_paid=NOW)) + assert isinstance(invoice.date_created, datetime) + assert isinstance(invoice.date_paid, datetime) + assert isinstance(invoice.expires_at, datetime) + assert not isinstance(invoice.date_created, int) + + +def test_id_and_merchant_id_are_int(): + invoice = Invoice.from_dict(_contract_response()) + assert isinstance(invoice.id, int) + assert isinstance(invoice.merchant_id, int) + + +def test_payer_is_none_for_unpaid_invoice(): + invoice = Invoice.from_dict(_contract_response(status="pending", payer=None)) + assert invoice.payer is None + + +def test_payer_is_set_for_paid_invoice(): + invoice = Invoice.from_dict( + _contract_response( + status="paid", payer=PAYER, date_paid=NOW, amount_paid=150000000 + ) + ) + assert invoice.payer == PAYER + + +def test_is_expired_true_when_expires_at_in_past(): + invoice = Invoice.from_dict(_contract_response(expires_at=PAST)) + assert invoice.is_expired is True + + +def test_is_expired_false_when_expires_at_in_future(): + invoice = Invoice.from_dict(_contract_response(expires_at=FUTURE)) + assert invoice.is_expired is False + + +def test_is_expired_false_when_no_expiry_set(): + invoice = Invoice.from_dict(_contract_response(expires_at=None)) + assert invoice.is_expired is False + + +def test_outstanding_equals_amount_minus_amount_paid(): + invoice = Invoice.from_dict( + _contract_response( + status="partially_paid", + amount=1000, + amount_paid=400, + ) + ) + assert invoice.outstanding == Decimal("600") + + +def test_outstanding_is_zero_when_fully_paid(): + invoice = Invoice.from_dict( + _contract_response(status="paid", amount=1000, amount_paid=1000) + ) + assert invoice.outstanding == Decimal("0") + + +def test_is_paid_true_only_when_status_is_paid(): + invoice = Invoice.from_dict( + _contract_response(status="paid", amount_paid=150000000) + ) + assert invoice.is_paid is True + + +@pytest.mark.parametrize("status", ["pending", "expired", "partially_paid", "refunded"]) +def test_is_paid_false_for_non_paid_statuses(status): + invoice = Invoice.from_dict(_contract_response(status=status)) + assert invoice.is_paid is False + + +def test_status_is_invoice_status_enum(): + for raw in ("pending", "paid", "expired", "partially_paid", "refunded"): + invoice = Invoice.from_dict(_contract_response(status=raw)) + assert isinstance(invoice.status, InvoiceStatus) + assert invoice.status.value == raw + + +def test_invalid_status_raises(): + with pytest.raises(InvalidRequestError) as exc_info: + Invoice.from_dict(_contract_response(status="bogus")) + assert exc_info.value.param == "status" + + +def test_boolean_id_is_rejected(): + with pytest.raises(InvalidRequestError) as exc_info: + Invoice.from_dict(_contract_response(id=True)) + assert exc_info.value.param == "id" + + +def test_boolean_merchant_id_is_rejected(): + with pytest.raises(InvalidRequestError) as exc_info: + Invoice.from_dict(_contract_response(merchant_id=False)) + assert exc_info.value.param == "merchant_id" + + +@pytest.mark.parametrize("field", ["id", "merchant_id"]) +def test_float_id_fields_are_rejected(field): + with pytest.raises(InvalidRequestError) as exc_info: + Invoice.from_dict(_contract_response(**{field: 1.9})) + assert exc_info.value.param == field + + +@pytest.mark.parametrize("field", ["date_created", "date_paid", "expires_at"]) +def test_float_timestamp_fields_are_rejected(field): + with pytest.raises(InvalidRequestError) as exc_info: + Invoice.from_dict(_contract_response(**{field: 1.9})) + assert exc_info.value.param == field + + +@pytest.mark.parametrize("field", ["id", "merchant_id"]) +def test_negative_id_fields_are_rejected(field): + with pytest.raises(InvalidRequestError) as exc_info: + Invoice.from_dict(_contract_response(**{field: -1})) + assert exc_info.value.param == field + + +def test_negative_expires_at_is_rejected(): + with pytest.raises(InvalidRequestError) as exc_info: + Invoice.from_dict(_contract_response(expires_at=-100)) + assert exc_info.value.param == "expires_at" + + +@pytest.mark.parametrize("field", ["id", "merchant_id"]) +def test_id_fields_exceeding_u64_max_are_rejected(field): + with pytest.raises(InvalidRequestError) as exc_info: + Invoice.from_dict(_contract_response(**{field: U64_MAX + 1})) + assert exc_info.value.param == field + + +def test_expires_at_exceeding_u64_max_is_rejected(): + with pytest.raises(InvalidRequestError) as exc_info: + Invoice.from_dict(_contract_response(expires_at=U64_MAX + 1)) + assert exc_info.value.param == "expires_at" + + +def test_from_dict_rejects_non_dict_input(): + with pytest.raises(InvalidRequestError) as excinfo: + Invoice.from_dict(["id", 42]) + + assert "expects a dict" in str(excinfo.value) + + +def test_from_dict_preserves_unknown_keys(): + invoice = Invoice.from_dict(_contract_response(contract_version=3)) + assert invoice.contract_version == 3 + assert invoice.to_dict()["contract_version"] == 3 + + +def test_round_trip_via_to_dict_from_dict(): + invoice = Invoice.from_dict( + _contract_response(status="partially_paid", amount_paid=500) + ) + round_tripped = Invoice.from_dict(invoice.to_dict()) + + assert round_tripped == invoice + assert isinstance(round_tripped.amount, Decimal) + assert isinstance(round_tripped.date_created, datetime) + assert round_tripped.outstanding == invoice.outstanding + + +def test_invoice_is_exported_from_package(): + assert shade.Invoice is Invoice + assert shade.InvoiceStatus is InvoiceStatus + assert issubclass(Invoice, ShadeObject) + + +def test_model_has_no_legacy_fields(): + invoice = Invoice.from_dict(_contract_response()) + for forbidden in ("line_items", "customer_email", "payment_url", "total"): + assert forbidden not in type(invoice).model_fields + assert not hasattr(invoice, forbidden) + + +def test_invoice_status_values_match_spec(): + assert {member.value for member in InvoiceStatus} == { + "pending", + "paid", + "expired", + "partially_paid", + "refunded", + }