diff --git a/lago_python_client/client.py b/lago_python_client/client.py index 9f1157f..8a6308f 100644 --- a/lago_python_client/client.py +++ b/lago_python_client/client.py @@ -26,6 +26,7 @@ from .invoices.clients import InvoiceClient from .mrrs.clients import MrrClient from .order_forms.clients import OrderFormClient +from .orders.clients import OrderClient from .organizations.clients import OrganizationClient from .overdue_balances.clients import OverdueBalanceClient from .payment_receipts.clients import PaymentReceiptClient @@ -187,6 +188,10 @@ def mrrs(self) -> MrrClient: def order_forms(self) -> OrderFormClient: return self._create_client(OrderFormClient, self.base_api_url, self.api_key) + @callable_cached_property + def orders(self) -> OrderClient: + return self._create_client(OrderClient, self.base_api_url, self.api_key) + @callable_cached_property def organizations(self) -> OrganizationClient: return self._create_client(OrganizationClient, self.base_api_url, self.api_key) diff --git a/lago_python_client/models/__init__.py b/lago_python_client/models/__init__.py index e1fa484..b19fd87 100644 --- a/lago_python_client/models/__init__.py +++ b/lago_python_client/models/__init__.py @@ -218,6 +218,15 @@ from .minimum_commitment import ( MinimumCommitmentResponse as MinimumCommitmentResponse, ) +from .order import ( + OrderExecute as OrderExecute, +) +from .order import ( + OrderExecutionRecordResponse as OrderExecutionRecordResponse, +) +from .order import ( + OrderResponse as OrderResponse, +) from .order_form import ( OrderFormMarkAsSigned as OrderFormMarkAsSigned, ) diff --git a/lago_python_client/models/order.py b/lago_python_client/models/order.py new file mode 100644 index 0000000..f610c6b --- /dev/null +++ b/lago_python_client/models/order.py @@ -0,0 +1,39 @@ +from typing import List, Optional + +from lago_python_client.base_model import BaseModel + +from ..base_model import BaseResponseModel +from .quote import QuoteBillingItemsResponse + + +class OrderExecute(BaseModel): + execution_mode: Optional[str] + + +class OrderExecutionRecordResponse(BaseResponseModel): + executed_at: Optional[str] + execution_mode: Optional[str] + invoice_id: Optional[str] + subscription_ids: Optional[List[str]] + terminated_subscription_ids: Optional[List[str]] + applied_coupon_ids: Optional[List[str]] + wallet_ids: Optional[List[str]] + errors: Optional[List[str]] + + +class OrderResponse(BaseResponseModel): + lago_id: str + number: str + status: str + order_type: str + execution_mode: Optional[str] + currency: Optional[str] + executed_at: Optional[str] + execution_record: Optional[OrderExecutionRecordResponse] + lago_organization_id: str + lago_customer_id: str + lago_order_form_id: str + created_at: str + updated_at: str + # Omitted from the webhook payloads, being a heavy blob. + billing_snapshot: Optional[QuoteBillingItemsResponse] diff --git a/lago_python_client/orders/__init__.py b/lago_python_client/orders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lago_python_client/orders/clients.py b/lago_python_client/orders/clients.py new file mode 100644 index 0000000..52d4e97 --- /dev/null +++ b/lago_python_client/orders/clients.py @@ -0,0 +1,53 @@ +from typing import ClassVar, Optional, Type + +import httpx + +from ..base_client import BaseClient +from ..mixins import ( + DEFAULT_TIMEOUT, + FindAllCommandMixin, + FindCommandMixin, +) +from ..models.order import OrderExecute, OrderResponse +from ..services.json import to_json +from ..services.request import ( + make_headers, + make_url, + send_post_request, +) +from ..services.response import Response, get_response_data, prepare_object_response + + +class OrderClient( + FindCommandMixin[OrderResponse], + FindAllCommandMixin[OrderResponse], + BaseClient, +): + API_RESOURCE: ClassVar[str] = "orders" + RESPONSE_MODEL: ClassVar[Type[OrderResponse]] = OrderResponse + ROOT_NAME: ClassVar[str] = "order" + + def execute( + self, + resource_id: str, + input_object: Optional[OrderExecute] = None, + timeout: Optional[httpx.Timeout] = DEFAULT_TIMEOUT, + ) -> OrderResponse: + """Carry out an order on demand, without waiting for its schedule.""" + payload = input_object.dict(exclude_none=True) if input_object else {} + + api_response: Response = send_post_request( + url=make_url( + origin=self.base_url, + path_parts=(self.API_RESOURCE, resource_id, "execute"), + ), + content=to_json({self.ROOT_NAME: payload}) if payload else None, + headers=make_headers(api_key=self.api_key), + timeout=timeout, + rate_limit_retry_config=self.rate_limit_retry_config, + ) + + return prepare_object_response( + response_model=self.RESPONSE_MODEL, + data=get_response_data(response=api_response, key=self.ROOT_NAME), + ) diff --git a/tests/fixtures/executed_order.json b/tests/fixtures/executed_order.json new file mode 100644 index 0000000..5fd1051 --- /dev/null +++ b/tests/fixtures/executed_order.json @@ -0,0 +1,36 @@ +{ + "order": { + "lago_id": "cc33cc33-cc33-cc33-cc33-cc33cc33cc33", + "number": "OR-2026-0001", + "status": "executed", + "order_type": "subscription_creation", + "execution_mode": "execute_in_lago", + "currency": "EUR", + "executed_at": "2026-07-01T00:00:00Z", + "execution_record": { + "executed_at": "2026-07-01T00:00:00Z", + "execution_mode": "execute_in_lago", + "invoice_id": null, + "subscription_ids": ["dd44dd44-dd44-dd44-dd44-dd44dd44dd44"], + "terminated_subscription_ids": [], + "applied_coupon_ids": ["ee55ee55-ee55-ee55-ee55-ee55ee55ee55"], + "wallet_ids": [], + "errors": [] + }, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_order_form_id": "aa11aa11-aa11-aa11-aa11-aa11aa11aa11", + "created_at": "2026-05-02T10:15:00Z", + "updated_at": "2026-07-01T00:00:00Z", + "billing_snapshot": { + "plans": [ + { + "id": "7a567a56-7a56-7a56-7a56-7a567a567a56", + "localId": "b5c1e2a4-4e1e-4a7f-9f0e-9c1a0c7e1f2b", + "type": "plan", + "payload": { "code": "premium_plan" } + } + ] + } + } +} diff --git a/tests/fixtures/order.json b/tests/fixtures/order.json new file mode 100644 index 0000000..97043a1 --- /dev/null +++ b/tests/fixtures/order.json @@ -0,0 +1,36 @@ +{ + "order": { + "lago_id": "cc33cc33-cc33-cc33-cc33-cc33cc33cc33", + "number": "OR-2026-0001", + "status": "created", + "order_type": "subscription_creation", + "execution_mode": "execute_in_lago", + "currency": "EUR", + "executed_at": null, + "execution_record": { + "executed_at": null, + "execution_mode": "execute_in_lago", + "invoice_id": null, + "subscription_ids": [], + "terminated_subscription_ids": [], + "applied_coupon_ids": [], + "wallet_ids": [], + "errors": [] + }, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_order_form_id": "aa11aa11-aa11-aa11-aa11-aa11aa11aa11", + "created_at": "2026-05-02T10:15:00Z", + "updated_at": "2026-05-02T10:15:00Z", + "billing_snapshot": { + "plans": [ + { + "id": "7a567a56-7a56-7a56-7a56-7a567a567a56", + "localId": "b5c1e2a4-4e1e-4a7f-9f0e-9c1a0c7e1f2b", + "type": "plan", + "payload": { "code": "premium_plan" } + } + ] + } + } +} diff --git a/tests/fixtures/order_index.json b/tests/fixtures/order_index.json new file mode 100644 index 0000000..87f8570 --- /dev/null +++ b/tests/fixtures/order_index.json @@ -0,0 +1,61 @@ +{ + "orders": [ + { + "lago_id": "cc33cc33-cc33-cc33-cc33-cc33cc33cc33", + "number": "OR-2026-0001", + "status": "created", + "order_type": "subscription_creation", + "execution_mode": "execute_in_lago", + "currency": "EUR", + "executed_at": null, + "execution_record": { + "executed_at": null, + "execution_mode": "execute_in_lago", + "invoice_id": null, + "subscription_ids": [], + "terminated_subscription_ids": [], + "applied_coupon_ids": [], + "wallet_ids": [], + "errors": [] + }, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_order_form_id": "aa11aa11-aa11-aa11-aa11-aa11aa11aa11", + "created_at": "2026-05-02T10:15:00Z", + "updated_at": "2026-05-02T10:15:00Z", + "billing_snapshot": {} + }, + { + "lago_id": "ff66ff66-ff66-ff66-ff66-ff66ff66ff66", + "number": "OR-2026-0002", + "status": "failed", + "order_type": "one_off", + "execution_mode": null, + "currency": "EUR", + "executed_at": null, + "execution_record": { + "executed_at": null, + "execution_mode": null, + "invoice_id": null, + "subscription_ids": [], + "terminated_subscription_ids": [], + "applied_coupon_ids": [], + "wallet_ids": [], + "errors": ["plan_not_found"] + }, + "lago_organization_id": "3c123c12-3c12-3c12-3c12-3c123c123c12", + "lago_customer_id": "2b012b01-2b01-2b01-2b01-2b012b012b01", + "lago_order_form_id": "bb22bb22-bb22-bb22-bb22-bb22bb22bb22", + "created_at": "2026-05-03T10:15:00Z", + "updated_at": "2026-05-03T10:15:00Z", + "billing_snapshot": {} + } + ], + "meta": { + "current_page": 1, + "next_page": null, + "prev_page": null, + "total_pages": 1, + "total_count": 2 + } +} diff --git a/tests/test_order_client.py b/tests/test_order_client.py new file mode 100644 index 0000000..4c755d1 --- /dev/null +++ b/tests/test_order_client.py @@ -0,0 +1,146 @@ +import os +from urllib.parse import urlencode + +import pytest +from pytest_httpx import HTTPXMock + +from lago_python_client.client import Client +from lago_python_client.exceptions import LagoApiError +from lago_python_client.models.order import OrderExecute + +ENDPOINT = "https://api.getlago.com/api/v1/orders" +ORDER_ID = "cc33cc33-cc33-cc33-cc33-cc33cc33cc33" + + +def mock_response(fixture_path): + this_dir = os.path.dirname(os.path.abspath(__file__)) + data_path = os.path.join(this_dir, fixture_path) + + with open(data_path, "rb") as response: + return response.read() + + +def test_valid_find_order_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + f"/{ORDER_ID}", + content=mock_response("fixtures/order.json"), + ) + response = client.orders.find(ORDER_ID) + + assert response.lago_id == ORDER_ID + assert response.number == "OR-2026-0001" + assert response.status == "created" + assert response.order_type == "subscription_creation" + assert response.execution_mode == "execute_in_lago" + assert response.executed_at is None + assert response.execution_record.errors == [] + assert response.lago_order_form_id == "aa11aa11-aa11-aa11-aa11-aa11aa11aa11" + assert response.billing_snapshot.plans[0].payload["code"] == "premium_plan" + + +def test_invalid_find_order_request(httpx_mock: HTTPXMock): + client = Client(api_key="invalid") + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + "/invalid", + status_code=404, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.orders.find("invalid") + + +def test_valid_find_all_order_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="GET", + url=ENDPOINT, + content=mock_response("fixtures/order_index.json"), + ) + response = client.orders.find_all() + + assert response["orders"][0].number == "OR-2026-0001" + assert response["orders"][1].status == "failed" + assert response["orders"][1].execution_mode is None + assert response["orders"][1].execution_record.errors == ["plan_not_found"] + assert response["meta"]["current_page"] == 1 + + +def test_valid_find_all_order_request_with_options(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + options = {"per_page": 2, "page": 1, "status[]": ["created"], "execution_mode[]": ["execute_in_lago"]} + + httpx_mock.add_response( + method="GET", + url=ENDPOINT + "?" + urlencode(options, doseq=True), + content=mock_response("fixtures/order_index.json"), + ) + response = client.orders.find_all(options) + + assert response["orders"][0].number == "OR-2026-0001" + assert response["meta"]["current_page"] == 1 + + +def test_valid_execute_order_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{ORDER_ID}/execute", + content=mock_response("fixtures/executed_order.json"), + ) + response = client.orders.execute(ORDER_ID) + + assert response.lago_id == ORDER_ID + assert response.status == "executed" + assert response.executed_at == "2026-07-01T00:00:00Z" + assert response.execution_record.subscription_ids == ["dd44dd44-dd44-dd44-dd44-dd44dd44dd44"] + assert response.execution_record.applied_coupon_ids == ["ee55ee55-ee55-ee55-ee55-ee55ee55ee55"] + + +def test_valid_execute_order_request_with_execution_mode(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{ORDER_ID}/execute", + match_content=b'{"order":{"execution_mode":"execute_in_lago"}}', + content=mock_response("fixtures/executed_order.json"), + ) + response = client.orders.execute(ORDER_ID, OrderExecute(execution_mode="execute_in_lago")) + + assert response.status == "executed" + + +def test_invalid_execute_order_request(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{ORDER_ID}/execute", + status_code=422, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.orders.execute(ORDER_ID) + + +def test_execute_order_request_with_missing_catalog_record(httpx_mock: HTTPXMock): + client = Client(api_key="886fe239-927d-4072-ab72-6dd345e8dd0d") + + httpx_mock.add_response( + method="POST", + url=ENDPOINT + f"/{ORDER_ID}/execute", + status_code=404, + content=b"", + ) + + with pytest.raises(LagoApiError): + client.orders.execute(ORDER_ID)