From a848f646319d7ed75a529c4f1b0020a98e249f41 Mon Sep 17 00:00:00 2001 From: Robert Segal Date: Fri, 26 Sep 2025 13:33:28 -0600 Subject: [PATCH] Added billing statements endpoints and mixin --- mpt_api_client/resources/billing/billing.py | 11 ++ mpt_api_client/resources/billing/mixins.py | 166 +++++++++++++++++ .../resources/billing/statements.py | 34 ++++ tests/resources/billing/test_billing.py | 3 + tests/resources/billing/test_mixins.py | 172 ++++++++++++++++++ tests/resources/billing/test_statements.py | 29 +++ 6 files changed, 415 insertions(+) create mode 100644 mpt_api_client/resources/billing/statements.py create mode 100644 tests/resources/billing/test_statements.py diff --git a/mpt_api_client/resources/billing/billing.py b/mpt_api_client/resources/billing/billing.py index 55b85cd..cd49681 100644 --- a/mpt_api_client/resources/billing/billing.py +++ b/mpt_api_client/resources/billing/billing.py @@ -1,6 +1,7 @@ from mpt_api_client.http import AsyncHTTPClient, HTTPClient from mpt_api_client.resources.billing.journals import AsyncJournalsService, JournalsService from mpt_api_client.resources.billing.ledgers import AsyncLedgersService, LedgersService +from mpt_api_client.resources.billing.statements import AsyncStatementsService, StatementsService class Billing: @@ -19,6 +20,11 @@ def ledgers(self) -> LedgersService: """Ledgers service.""" return LedgersService(http_client=self.http_client) + @property + def statements(self) -> StatementsService: + """Statements service.""" + return StatementsService(http_client=self.http_client) + class AsyncBilling: """Billing MPT API Module.""" @@ -35,3 +41,8 @@ def journals(self) -> AsyncJournalsService: def ledgers(self) -> AsyncLedgersService: """Ledgers service.""" return AsyncLedgersService(http_client=self.http_client) + + @property + def statements(self) -> AsyncStatementsService: + """Statements service.""" + return AsyncStatementsService(http_client=self.http_client) diff --git a/mpt_api_client/resources/billing/mixins.py b/mpt_api_client/resources/billing/mixins.py index a57c01e..8e0b3ed 100644 --- a/mpt_api_client/resources/billing/mixins.py +++ b/mpt_api_client/resources/billing/mixins.py @@ -2,6 +2,8 @@ # TODO: Consider moving Regeneratable mixins to http/mixins if publishable and activatable are moved +# TODO: Consider reorganizing functions in mixins to reduce duplication and differences amongst +# different domains class RegeneratableMixin[Model]: """Regeneratable mixin adds the ability to regenerate resources.""" @@ -174,3 +176,167 @@ async def queue(self, resource_id: str, resource_data: ResourceData | None = Non return await self._resource_action( # type: ignore[attr-defined, no-any-return] resource_id, "POST", "queue", json=resource_data ) + + +class IssuableMixin[Model]: + """Issuable mixin adds the ability to issue resources.""" + + def issue(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Issue resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "issue", json=resource_data + ) + + def cancel(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Cancel resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "cancel", json=resource_data + ) + + def error(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Error resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "error", json=resource_data + ) + + def pending(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Pending resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "pending", json=resource_data + ) + + def queue(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Queue resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "queue", json=resource_data + ) + + def retry(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Retry resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "retry", json=resource_data + ) + + def recalculate(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Recalculate resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "recalculate", json=resource_data + ) + + +class AsyncIssuableMixin[Model]: + """Issuable mixin adds the ability to issue resources.""" + + async def issue(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Issue resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "issue", json=resource_data + ) + + async def cancel(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Cancel resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "cancel", json=resource_data + ) + + async def error(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Error resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "error", json=resource_data + ) + + async def pending(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Pending resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "pending", json=resource_data + ) + + async def queue(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Queue resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "queue", json=resource_data + ) + + async def retry(self, resource_id: str, resource_data: ResourceData | None = None) -> Model: + """Retry resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "retry", json=resource_data + ) + + async def recalculate( + self, resource_id: str, resource_data: ResourceData | None = None + ) -> Model: + """Recalculate resource. + + Args: + resource_id: Resource ID + resource_data: Resource data will be updated + """ + return await self._resource_action( # type: ignore[attr-defined, no-any-return] + resource_id, "POST", "recalculate", json=resource_data + ) diff --git a/mpt_api_client/resources/billing/statements.py b/mpt_api_client/resources/billing/statements.py new file mode 100644 index 0000000..66e152c --- /dev/null +++ b/mpt_api_client/resources/billing/statements.py @@ -0,0 +1,34 @@ +from mpt_api_client.http import AsyncService, Service +from mpt_api_client.http.mixins import AsyncUpdateMixin, UpdateMixin +from mpt_api_client.models import Model +from mpt_api_client.resources.billing.mixins import AsyncIssuableMixin, IssuableMixin + + +class Statement(Model): + """Statement resource.""" + + +class StatementsServiceConfig: + """Statements service configuration.""" + + _endpoint = "/public/v1/billing/statements" + _model_class = Statement + _collection_key = "data" + + +class StatementsService( + UpdateMixin[Statement], + IssuableMixin[Statement], + Service[Statement], + StatementsServiceConfig, +): + """Statements service.""" + + +class AsyncStatementsService( + AsyncUpdateMixin[Statement], + AsyncIssuableMixin[Statement], + AsyncService[Statement], + StatementsServiceConfig, +): + """Async Statements service.""" diff --git a/tests/resources/billing/test_billing.py b/tests/resources/billing/test_billing.py index d6bde95..ce21e44 100644 --- a/tests/resources/billing/test_billing.py +++ b/tests/resources/billing/test_billing.py @@ -3,6 +3,7 @@ from mpt_api_client.resources.billing.billing import AsyncBilling, Billing from mpt_api_client.resources.billing.journals import AsyncJournalsService, JournalsService from mpt_api_client.resources.billing.ledgers import AsyncLedgersService, LedgersService +from mpt_api_client.resources.billing.statements import AsyncStatementsService, StatementsService @pytest.fixture @@ -20,6 +21,7 @@ def async_billing(async_http_client): [ ("journals", JournalsService), ("ledgers", LedgersService), + ("statements", StatementsService), ], ) def test_billing_properties(billing, property_name, expected_service_class): @@ -35,6 +37,7 @@ def test_billing_properties(billing, property_name, expected_service_class): [ ("journals", AsyncJournalsService), ("ledgers", AsyncLedgersService), + ("statements", AsyncStatementsService), ], ) def test_async_billing_properties(async_billing, property_name, expected_service_class): diff --git a/tests/resources/billing/test_mixins.py b/tests/resources/billing/test_mixins.py index 1a4b039..c27dcec 100644 --- a/tests/resources/billing/test_mixins.py +++ b/tests/resources/billing/test_mixins.py @@ -5,8 +5,10 @@ from mpt_api_client.http.async_service import AsyncService from mpt_api_client.http.service import Service from mpt_api_client.resources.billing.mixins import ( + AsyncIssuableMixin, AsyncRecalculatableMixin, AsyncRegeneratableMixin, + IssuableMixin, RecalculatableMixin, RegeneratableMixin, ) @@ -49,6 +51,24 @@ class DummyAsyncRecalculatableService( _collection_key = "data" +class DummyIssuableService( + IssuableMixin[DummyModel], + Service[DummyModel], +): + _endpoint = "/public/v1/dummy/issuable/" + _model_class = DummyModel + _collection_key = "data" + + +class DummyAsyncIssuableService( + AsyncIssuableMixin[DummyModel], + AsyncService[DummyModel], +): + _endpoint = "/public/v1/dummy/issuable/" + _model_class = DummyModel + _collection_key = "data" + + @pytest.fixture def regeneratable_service(http_client): return DummyRegeneratableService(http_client=http_client) @@ -69,6 +89,16 @@ def async_recalculatable_service(async_http_client): return DummyAsyncRecalculatableService(http_client=async_http_client) +@pytest.fixture +def issuable_service(http_client): + return DummyIssuableService(http_client=http_client) + + +@pytest.fixture +def async_issuable_service(async_http_client): + return DummyAsyncIssuableService(http_client=async_http_client) + + @pytest.mark.parametrize( ("action", "input_status"), [ @@ -321,3 +351,145 @@ async def test_async_recalculate_resource_actions_no_data( assert request.content == request_expected_content assert recalc_obj.to_dict() == response_expected_data assert isinstance(recalc_obj, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("issue", {"id": "OBJ-0000-0001", "status": "update"}), + ("cancel", {"id": "OBJ-0000-0001", "status": "update"}), + ("error", {"id": "OBJ-0000-0001", "status": "update"}), + ("pending", {"id": "OBJ-0000-0001", "status": "update"}), + ("queue", {"id": "OBJ-0000-0001", "status": "update"}), + ("retry", {"id": "OBJ-0000-0001", "status": "update"}), + ("recalculate", {"id": "OBJ-0000-0001", "status": "update"}), + ], +) +def test_issuable_resource_actions(issuable_service, action, input_status): + request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/issuable/OBJ-0000-0001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + issuable_obj = getattr(issuable_service, action)("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert issuable_obj.to_dict() == response_expected_data + assert isinstance(issuable_obj, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("issue", None), + ("cancel", None), + ("error", None), + ("pending", None), + ("queue", None), + ("retry", None), + ("recalculate", None), + ], +) +def test_issuable_resource_actions_no_data(issuable_service, action, input_status): + request_expected_content = b"" + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/issuable/OBJ-0000-0001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + issuable_obj = getattr(issuable_service, action)("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert issuable_obj.to_dict() == response_expected_data + assert isinstance(issuable_obj, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("issue", {"id": "OBJ-0000-0001", "status": "update"}), + ("cancel", {"id": "OBJ-0000-0001", "status": "update"}), + ("error", {"id": "OBJ-0000-0001", "status": "update"}), + ("pending", {"id": "OBJ-0000-0001", "status": "update"}), + ("queue", {"id": "OBJ-0000-0001", "status": "update"}), + ("retry", {"id": "OBJ-0000-0001", "status": "update"}), + ("recalculate", {"id": "OBJ-0000-0001", "status": "update"}), + ], +) +async def test_async_issuable_resource_actions(async_issuable_service, action, input_status): + request_expected_content = b'{"id":"OBJ-0000-0001","status":"update"}' + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/issuable/OBJ-0000-0001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + issuable_obj = await getattr(async_issuable_service, action)("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert issuable_obj.to_dict() == response_expected_data + assert isinstance(issuable_obj, DummyModel) + + +@pytest.mark.parametrize( + ("action", "input_status"), + [ + ("issue", None), + ("cancel", None), + ("error", None), + ("pending", None), + ("queue", None), + ("retry", None), + ("recalculate", None), + ], +) +async def test_async_issuable_resource_actions_no_data( + async_issuable_service, action, input_status +): + request_expected_content = b"" + response_expected_data = {"id": "OBJ-0000-0001", "status": "new_status"} + with respx.mock: + mock_route = respx.post( + f"https://api.example.com/public/v1/dummy/issuable/OBJ-0000-0001/{action}" + ).mock( + return_value=httpx.Response( + status_code=httpx.codes.OK, + headers={"content-type": "application/json"}, + json=response_expected_data, + ) + ) + issuable_obj = await getattr(async_issuable_service, action)("OBJ-0000-0001", input_status) + + assert mock_route.call_count == 1 + request = mock_route.calls[0].request + + assert request.content == request_expected_content + assert issuable_obj.to_dict() == response_expected_data + assert isinstance(issuable_obj, DummyModel) diff --git a/tests/resources/billing/test_statements.py b/tests/resources/billing/test_statements.py new file mode 100644 index 0000000..629838c --- /dev/null +++ b/tests/resources/billing/test_statements.py @@ -0,0 +1,29 @@ +import pytest + +from mpt_api_client.resources.billing.statements import AsyncStatementsService, StatementsService + + +@pytest.fixture +def statements_service(http_client): + return StatementsService(http_client=http_client) + + +@pytest.fixture +def async_statements_service(async_http_client): + return AsyncStatementsService(http_client=async_http_client) + + +@pytest.mark.parametrize( + "method", + ["get", "update", "issue", "cancel", "error", "pending", "queue", "retry", "recalculate"], +) +def test_mixins_present(statements_service, method): + assert hasattr(statements_service, method) + + +@pytest.mark.parametrize( + "method", + ["get", "update", "issue", "cancel", "error", "pending", "queue", "retry", "recalculate"], +) +def test_async_mixins_present(async_statements_service, method): + assert hasattr(async_statements_service, method)