diff --git a/cli/src/m2_market/ledger.py b/cli/src/m2_market/ledger.py new file mode 100644 index 0000000..d49d549 --- /dev/null +++ b/cli/src/m2_market/ledger.py @@ -0,0 +1,96 @@ +"""HTTP client for the m2-ledger service (contracts/ledger-api.md).""" + +from __future__ import annotations + +import httpx + +DEFAULT_TIMEOUT = 10.0 + + +class LedgerError(Exception): + """Any ledger error not covered by a more specific exception.""" + + def __init__(self, message: str, status_code: int | None = None, body: dict | None = None): + super().__init__(message) + self.status_code = status_code + self.body = body or {} + + +class InsufficientFunds(LedgerError): + """Raised on 402 — insufficient balance, nothing written.""" + + +class DuplicateRef(LedgerError): + """Raised on 409 — same ref already settled/refunded (idempotency guard).""" + + +class LedgerClient: + def __init__(self, base_url: str, api_key: str, timeout: float = DEFAULT_TIMEOUT): + self._client = httpx.Client( + base_url=base_url, + headers={"X-API-Key": api_key}, + timeout=timeout, + ) + + def close(self) -> None: + self._client.close() + + def __enter__(self) -> "LedgerClient": + return self + + def __exit__(self, *exc_info) -> None: + self.close() + + def _raise_for_error(self, response: httpx.Response) -> None: + try: + body = response.json() + except ValueError: + body = {} + message = body.get("error", response.text) + if response.status_code == 402: + raise InsufficientFunds(message, response.status_code, body) + if response.status_code == 409: + raise DuplicateRef(message, response.status_code, body) + raise LedgerError(message, response.status_code, body) + + def balance(self, operator_id: str) -> dict: + response = self._client.get(f"/balance/{operator_id}") + if response.status_code != 200: + self._raise_for_error(response) + return response.json() + + def install( + self, + buyer: str, + seller: str, + amount: int, + ref: str, + platform_pct: float | None = None, + ) -> dict: + body = {"buyer": buyer, "seller": seller, "amount": amount, "ref": ref} + if platform_pct is not None: + body["platform_pct"] = platform_pct + response = self._client.post("/tx/install", json=body) + if response.status_code != 200: + self._raise_for_error(response) + return response.json()["grant"] + + def refund(self, ref: str) -> list: + response = self._client.post("/tx/refund", json={"ref": ref}) + if response.status_code != 200: + self._raise_for_error(response) + return response.json()["tx_ids"] + + def transactions(self, **filters) -> list: + response = self._client.get("/tx", params=filters) + if response.status_code != 200: + self._raise_for_error(response) + return response.json()["transactions"] + + def license(self, operator_id: str, listing_id: str) -> dict | None: + response = self._client.get(f"/license/{operator_id}/{listing_id}") + if response.status_code == 404: + return None + if response.status_code != 200: + self._raise_for_error(response) + return response.json()["grant"]